Skip to content

feat(ai_engine): 对外契约 + 动作分流 + 入口预检与出参成色(Refs #171 #53) - #181

Merged
johnnyzhang-eng merged 12 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/ai-engine-ports-and-strategy
Aug 12, 2026
Merged

feat(ai_engine): 对外契约 + 动作分流 + 入口预检与出参成色(Refs #171 #53)#181
johnnyzhang-eng merged 12 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/ai-engine-ports-and-strategy

Conversation

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

变更内容

windup_ai_engine 的对外契约与分流决策:

  • ports/CharacterGeneratorPort 是 server 唯一入口,由 import-linter 的分层契约强制(app.web / app.worker 不得直连 ai_engine
  • strategy/ROUTE_MATRIX 动作类型 → 生成路线;VideoFrameStrategy 实测通路
  • impl/CharacterGenerator — 母版预检 → 选路线 → 出帧 → 脚线对齐 → 量成色 → 出参
  • master_check + _subject — 入口可生成性预检

生成路线矩阵(架构契约,改它 = 改产线)

动作 路线 依据
walk / run VIDEO_I2V 逐帧独立生成锁不住「哪条腿在前」→ 踢踏舞;视频天生连贯、腿自然交替
jump / attack VIDEO_I2V 同上;但属一次性动作,抽帧不闭环,走 pick_oneshot
idle VIDEO_I2V 见下方与 #53 的差异
hit PER_FRAME 离散姿势,单帧可编辑价值高、无连续步态。未实现,调用即抛错

依据见 #35。另记录该矩阵形状的已知边界(写进 strategy/base.py 注释,免得后来者按错误前提扩展):它是「动作类型 → 路线」一对一映射,隐含前提是「路线由动作的物理性质唯一决定」。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立——同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。

四类静默失败修复

本仓吃过四次「看起来成功的错结果」,这批全部改成在边界上抛错。

1. 未实现的路线返回 [b""] * n_frames 调用方拿到的 GeneratedAction 帧数对、时长对、无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进 character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出已装配了哪些;strategy 吐出空帧时抛 ValueError

2. 抽帧会静默少给帧。 pick_cycle / pick_oneshot 在源帧不足时长度不足且不报错,而 frame_durations(..., len(frames))实际长度现算——时长表跟帧数自洽,server 看不出异常,用户拿到一段步子没走完的循环。加帧数对账,放在 generator 而不是某个 strategy 里,将来任何新路线都受同一约束。

3. 入口零校验。 实测(2026-08-07):喂一张「人物在画板前作画」的图请求 walk,全程无一处报错,产出 16 帧构图完整的错角色、钱花完。加 check_master 预检,判四类本地零成本可判的形态问题——不可解码 / 无主体(全透明或全同色)/ 主体过小 / 比例过扁。

MasterRejected 带机器可读的 code,与其他异常分工明确:它 = 调用方输入不行,同一张母版重试多少次都一样,server 该映射成 4xx、翻成「请换一张母版」、不要重试NotImplementedError / 其他 ValueError = 引擎侧装配或产出出了问题,属 5xx、要人介入,让用户换母版是把锅甩错地方。

阈值 REJECT_ASPECT 由交付画布几何推出2*FILL_W/FILL_H)而非拍定,并有测试锁住这个推导关系——改了 pack.py 的填充比而预检不动,就会放行一批下游装不下的母版。

判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清「本层不判什么、为什么」。

4. 出参无法表达成色。 一段每帧都一样的 walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。加 ActionQuality,三个字段各自不可由其他两个推导:

  • motion_scale 相邻帧差的绝对尺度。必须单独给:dead_frame_mask 两条判据都是相对的,整段冻结时 d 全为 0、两条不等式变成 0<0一帧死帧都报不出(实测 12 帧全同报 0 死帧)——相对判据天生看不见「整体没动」
  • dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要的是「哪几帧」)
  • loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在对齐之后量,量的是用户真正看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成「完美闭环」。一次性动作不给:首尾姿态本就不同,发一个必然难看的数会诱导错误决定

刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数,上层拿它做不了任何决定。

引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。

#53 原设计的两处差异

# #53 原设计 现状 依据
idle → PROC_IDLE(¥0 程序化局部呼吸 Idle-B) idle → VIDEO_I2VPROC_IDLE 枚举与 ProcIdleStrategy 一并移除 程序化呼吸做不出可用效果,放弃、认这份 i2v 的钱。不留没有实现的枚举值
抠图 = rembg onnxruntime 直跑 u2netp rembg 依赖链在 3.12 无轮子;同模型同质量

一处顺手修掉的用户可见缺陷

Python 3.11+ 改了 str-mixin 枚举的 __format__f"{action.action}" 现在给出 ActionType.WALK 而不是 walk(3.12.13 实测),而这串字经 server 变成用户看到的 SSE 进度文案。所有进度文案改取 .value

边界(与作者对齐)

ai_engine 只产出帧 bytes + 逐帧时长 + 成色,不碰存储 / 数据库 / 任务状态。依据是「谁掌握租户与配额上下文」——上传对象存储要知道 bucket、路径规则、归属项目、配额,这些全在 server;ai_engine 若自持存储,等于把租户概念下沉到一个只做图像计算的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受;帧数或分辨率显著上升时需重估)。

关联与依赖

Refs #171 · Refs #53 · Refs #35 · 是 #152 的前置

stack 声明:本分支 stack 在 #172 / #179 / #180 之上,那三个合并后 rebase,届时 diff 只剩 ports / strategy / impl / master_check 这一层。

本地验证

uv run ruff check .   All checks passed!
uv run lint-imports   Contracts: 2 kept, 0 broken.
uv run pytest -q      全绿

变异测试 6/6 被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、抠图贴边采样。

待对齐

CharacterCard 的字段在 ai_engine零读取(视频路线的角色身份由母版图像承载,提示词只取 action.facing,所以不同角色跑出来的提示词是同一份)。它是为逐帧路线(用 desc 组提示词)与渲染路线(model_3d_ref,见 #122)预留的入参,已在 ports docstring 写明。是否现在就精简,听评审意见。

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
windup Ignored Ignored Preview Aug 12, 2026 3:25am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Found four high-confidence regressions in the new public contract and runtime path. Static validation passed with python3 -m compileall and git diff --check; the test suite could not run because this runner has neither uv nor pytest installed.

View job run

Findings without inline locations

  • backend/uv.lock:1892: [P1] Keep the lockfile synchronized with the unchanged manifests. This regenerated lock drops pydantic[email], passlib[bcrypt], redis, resend, and pytest-cov, even though the workspace pyproject.toml files still require them. A frozen sync from this lock therefore produces an incomplete environment: EmailStr validation and the framework auth/email/Redis imports can fail, and pytest is configured with --cov but no coverage plugin. Please regenerate uv.lock from the current manifests without removing the existing dependencies.

Comment thread backend/packages/common/src/windup_common/models/character.py Outdated
Comment thread backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py Outdated
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

已 force-push:把这条依赖链重排成真正的线性 stack。评审锚点会移位,说明原因。

问题:GitHub 的 MERGEABLE 只计算"对当前 main",不计算"前面几个先合进去之后"。本地实测按依赖顺序合并:main → #172#179#180 都干净,#181 时 3~4 个文件冲突matte.py / test_matte_provider.py / test_character_contract.py / quality.py)。

机制#181 / #182 此前是各自独立基于 main、靠"同步提交"携带前置分片内容的副本。合并 #181 时的共同祖先里没有 matte.py(它属于 #179),于是两边各自"新增"同一个文件 = add/add 冲突 —— 即使一边是严格超集,git 也无法自动合并。所以逐文件对齐内容没用,必须让祖先里真有那些文件。

处理:改成 #172#179#180#181#182 的线性 stack,每个分支真正包含前置分支的提交。副作用是那些"同步上游/下游"的提交全部变成冗余,已在重排中丢弃。

内容变化(重排本身不改逻辑,两处例外,均已核对):

验收(重排后逐项跑过):

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

这五个 PR 已达 ready:无待追加改动、CI 通过、AI review 意见全部 resolved。可以开始 review。

依赖顺序(已重排为线性 stack,逐级包含前一片的提交):

#172 共享契约  →  #179 Provider 与抠图  →  #180 出帧工具箱  →  #181 引擎契约与串联  →  #182 任务编排

#180 零依赖于前两片的业务逻辑(纯 PIL / numpy + 真实视频实测),想先看小的可以从它入手。

本地已验的三项(每次推送后重跑):

  • 按依赖顺序合并 main → #172 → #179 → #180 → #181 → #182 五步全干净
  • 逐分支 CI 原样命令全过,测试数 111 → 185 → 266 → 307 → 337 单调递增
  • 全部合入后应用可启动,app.openapi() 口径 29 条路由,与 main 一字不差(零新增零删除)

端到端实证:2026-08-11 用这条链路(不是旁路脚本)从零跑通两个全新角色的走路序列帧——文生图出母版 → i2v → 抽帧 → 选帧 → 抠图 → 像素化 → 对齐 → 打包。


三条已知缺陷,代码在本批 PR 内,已独立立项跟踪,不在本批修复:

三条都不影响流程成功与 CI,属品相问题。选择独立跟踪而不是塞进本批,是为了不让改动范围与 Issue 脱节;其中 #197 的可行方向尚未实现也未验证,如实说明。

@johnnyzhang-eng
johnnyzhang-eng requested a review from nighca August 11, 2026 05:48
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-ports-and-strategy branch 2 times, most recently from 5026970 to 00e0ae7 Compare August 11, 2026 09:18
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

冲突已解,重排到最新 main。 #172 已合入,依赖链缩短为 #179#180#181#182

冲突根因:#172 合入后本分支还带着它 squash 前的三个提交,与 main 上的 squash 版 add/add 冲突;同时 main 合了 #110 / #194 等 10 个提交,已全部重排吸收。

两种口径都验过:逐个直接对 main 合干净(GitHub 的判据)、按依赖顺序连合也干净。逐分支 CI 全过(212 → 293 → 334 → 374,单调递增)。

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

已重排到最新 main。重排时 character_generator 等 7 个文件被基座版覆盖(丢了母版预检和成色量化),已取回;uv.lock 和 framework/pyproject.toml 也回到 main 版重锁,修掉 bcrypt 找不到的问题。

johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
johnnyzhang-eng and others added 5 commits August 12, 2026 10:40
providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。
2026-08-07 拉网关 OpenAPI spec 逐个核对:平台现有 69 个 POST 视频端点,其中 22 个
图生视频**全部**在 FAL 队列面 /queue/... 下,首帧一律是 URL 形态字段(image_url /
start_image_url),同日实测送 base64 dataURI 无一能用。原 SufyVideoProvider 建在
OpenAI 风格 /v1/videos + input_reference dataURI 上,是过时的接口形状——在它上面打的
两处补丁方向错了,一并回退:

- _needs_image_list / _IMAGE_LIST_MODELS 里新增的 kling-v3-omni / kling-v3
- _assert_reference_registered / ReferenceIgnoredError 及其 3 条测试

新增 FalQueueVideoProvider 与旧实现并存(没有实测证据说 /v1/videos 已坏,sora 系可能
仍只在那一面)。要点:

1) 模型 → 端点的显式硬表 FAL_I2V_ENDPOINTS,不拼路径。每家有三样东西不同且都猜不出
   来:提交路径的型号段;首帧字段名(同是 kling,o3 / v2.5-turbo 叫 image_url,
   v3 / v2.6 / o1 叫 start_image_url);轮询前缀(**不是**提交路径 + /requests,
   kling 六个型号共用 /queue/fal-ai/kling-video/requests/{id})。未登记的模型抛
   UnknownVideoModelError,不做前缀匹配、不做兜底——猜出一条"存在但语义不同"的路径
   (如把 image-to-video 猜成 reference-to-video)会正常出片、正常计费。

2) i2v 契约冲突:Protocol 收 bytes,FAL 面只吃公网 URL。选择"provider 自己适配",
   Protocol 签名不动——新增 FirstFrameUploader port,provider 构造时必传,内部把补边
   后的首帧换成 URL。调用方零改动;母版已在公网时用 PreUploadedFirstFrame 复用该
   URL、不重传。

3) 失败一律显式抛错,不静默降级:spec 明写「任务失败时后端也返回 COMPLETED,通过
   detail 区分」,故 COMPLETED 还要查 detail;认不出的 status 当失败(继续轮询会把
   "协议变了"伪装成"生成太慢");超时抛 VideoJobTimeoutError;参数校验在上传首帧之前
   完成;下载复用既有 _download(重试 + 长度校验,治"视频已生成、费用已产生,下载断
   一次整单作废")。

FAL 面鉴权是 Authorization: Key(不是 Bearer),base_url 需从 /v1 退回网关根
(/queue 与 /v1 平级)。两处都有 spec 依据,已写进注释与测试。

37 条新测试全程 mock 不联网;11 个变异(错端点 / 错字段名 / 错轮询前缀 / 去掉各处抛错
/ 去掉下载重试 / 参数校验挪到上传后)逐个确认能被测到,全部 KILLED。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
机器审 PR 1024XEngineer#179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是
网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx
只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上
client 级 headers —— API key 因此发给了那个域名。

改法:
- 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接,
  那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。
- Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。
- 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口),
  未 import 其私有函数,免得被上游改名。
- 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。
- 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、
  费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取"
  的注释同步更正 —— 它正是这个泄漏的出处。

变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把
:443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
视频路线的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试。

slicing/  视频 → 帧序列
  extract   解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间;
  quality   帧质量诊断(死帧 / 糊帧判据,只作诊断不进选帧,理由见 loop docstring)

postprocess/  帧 → 交付级序列帧
  pixelate  母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化
  pack      脚线对齐 / sprite sheet / GIF
  rootmotion 逐帧时长(关键帧加长定格,等时长会让动作发飘)

prompt/ + master_prep.py  按动作类型选提示词、按动作预处理母版

两处实测挣得的修复一并带上:

1) 画布横向裁切(postprocess/pack.py)
   align_bottom_center 的三条缩放分支只按高度定标,是"主体是纵向长条"的人形先验。
   横向长条主体按同一系数缩放后宽度超出 cell,被 alpha_composite 以负 dest 静默丢像素,
   PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测狐狸母版 w/h=1.78 丢 27px(鼻尖+尾尖),
   w/h=2.0 只剩 79.9% 内容。加宽度兜底 fill_w=0.96;人形 w/h 0.3–1.1 时该约束恒不生效,
   产物逐像素不变。

2) 步态周期误检(slicing/loop.py)三个坑,四段真 i2v 视频实测
   a. 角色整体平移让 d(p) 单调上升,argmin 滑到搜索窗边界交出假周期。
      实测骷髅走路:不消平移时曲线 40/56 段在上升,只剩 22/42/52 三个浅坑,argmin=22;
      加 _deskew 消平移后整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。
   b. 搜索窗上界 n//2 把真周期挡在窗外(待机真周期 62 > pmax 60)。改为 total*0.6。
   c. 谐波:22 接近真周期的一半,半周期闭环 = 末帧接回首帧时左右腿瞬间互换。
      改为在基周期整数倍里按归一化接缝复选,优先最小倍数。
   测不到可信凹陷(prominence < 0.25)时判"无周期",退化成全片均匀取、不硬闭环——
   实测骑士待机只有 31 帧,旧算法曲线单调、argmin 落在搜索窗下界 6 交出边界假值。

实测对照(n=16,接缝 = 末→首差 ÷ 组内相邻差均值,越接近 1 越闭合)
  骷髅走路 3.07→1.96 | 骑士走路 1.29→0.87 | 骑士待机 9.31→1.39 | 骑士奔跑 1.77→0.81
  待机那条最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。

消融:改善全部来自 _deskew + 谐波复选。追加的"死帧避让 + 冻结裁剪"两个样本无变化、
两个变差(奔跑接缝 0.81→2.00),已回退,quality 只留作诊断。
FAL 队列面已随 1024XEngineer#179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来,
而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。
johnnyzhang-eng and others added 6 commits August 12, 2026 10:40
providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。
管线内部按 PIL.Image 处理,跨模块边界(strategy → generator → ports 出参)按 PNG bytes
传递。这对转换此前在 strategy/concrete.py 与 impl/character_generator.py 各写了一份完整
拷贝,收成 _imgio.py 唯一定义(to_png / from_png)。编码参数一旦分叉,会在"某些帧丢了
alpha"这类只在画面上体现、不报错的地方出问题。

同步 stack:本分支重新对齐到 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 的当前终态,同名文件与三者逐字节一致。
两头各加一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入;
出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。

此前 ports 与 impl 里所有 raise 都在输出侧,对 master 不做任何前置判定。
2026-08-07 实测:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错,
16 帧构图完整的错角色出完、钱花完。

check_master 判三类**本地零成本可判**的形态问题,不通过抛 MasterRejected:
- UNDECODABLE 不是图 / 截断
- NO_SUBJECT 全透明或全同色,没有可动的东西
- SUBJECT_TOO_SMALL 包围盒最短边 < 8px(放大 20 倍是色块不是角色),
  或主体占比 < 0.1%(对角散落两粒噪点会把包围盒撑到整幅,边长检查全过)
- ASPECT_TOO_WIDE 主体 w/h 超阈值,方形画布只能把角色硬缩成一条

REJECT_ASPECT 由交付画布几何推出(2*FILL_W/FILL_H)而非拍脑袋,并有测试锁住
这个推导关系——改了 pack.py 的填充比而这里不动,预检会放行一批下游装不下的母版。

MasterRejected 带机器可读的 code:server 据此选文案、判 4xx-不重试,与
NotImplementedError / 其他 ValueError(引擎侧问题,5xx,要人介入)分工明确。
判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清"本层不判什么"。

GeneratedAction 此前只能表达"生成完了",不能表达"生成得怎么样":一段每帧都一样的
walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。

三个字段各自不可由其他两个推导:
- motion_scale 相邻帧差的**绝对**尺度。必须单独给:dead_frame_mask 两条判据都是
  相对的,整段冻结时 d 全为 0、两条不等式变成 0<0,一帧死帧都报不出(实测 12 帧
  全同报 0 死帧)——相对判据天生看不见"整体没动"。
- dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要"哪几帧")
- loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在**对齐之后**量,量的是用户真正
  看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成"完美闭环"。
  一次性动作(jump/attack)不给:首尾姿态本就不同,给个必然难看的数会诱导错误决定。

刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数。

引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按
场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。

底色采样此前贴边取。视频帧最外一两行/列常是**编码器边缘伪影**而非底色:实测 9 段真
i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)被判"底不均匀"而跳过清理——底色清理在
真实路径上等于从不生效。逐一查证全部由最外圈造成(某视频最右一列整列纯黑 std 50.4,
待机视频最顶一行 std 8.4 恰好压线越过 8)。往里让 2px 后 144 帧零误跳,三张静态母版的
取样中位色一个字节未变。

17 条新用例。变异测试 6/6 全部被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、
motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、贴边采样。

其中"去掉最短边检查"最初**没被杀**——样本用的小方块占比也不达标,占比那条接住了它。
换成细长条(占比 1.3% 远超下限,只有边长这条能拦)后才真正独立。写完就绿的测试等于没写。
机器审在本 PR 报的三条 P2,两条同源:契约里存在"能填/能读、但与另一处矛盾或不生效"的
字段。

一、GeneratedAction.fps 删除。它抄自入参,而 durations 按动作查表得来,两者描述同一段
   素材的不同播放速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧,取哪个看消费方心情。
   逐帧 ms 严格更能表达(关键帧定格),所以保 durations、删 fps;真要单一帧率由消费方算。
   连带删除 ActionSpec.fps(在 feat/character-domain-models 里,本分支同步)。

二、删掉一条为缺陷背书的测试。此处曾有 test_loop_mode_currently_changes_nothing,把
   "传 pingpong / none 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒
   删注释"。那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个
   行为背书。现改为断言字段确实不存在——ActionSpec.loop 与 LoopMode 都已移除。
   同理,test_generate_walk_is_wired_end_to_end 里的 `assert out.fps == action.fps`
   换成断言时长确实来自动作查表(walk = 125ms/帧)。

三、抽帧改流式(改动本体在 feat/ai-engine-frame-toolkit,本分支同步)。121 帧 720p 真实
   视频抽 16 帧,进程 RSS 峰值 488 → 126 MiB。

变异测试:把 GeneratedAction.fps 加回去 1 条红;把 durations 改成固定 50ms 不查表 1 条红。
CI:ruff / import-linter 2 contracts / pytest 276 passed。
上一个提交让交付画布可以非方,这条紧接着补上被它架空的东西:master_check 的
REJECT_ASPECT 推导默认画布是方形 —— FILL_W 与 FILL_H 是**同一条边长**的两个比例。
画布能非方之后前提不成立了,同一条推导做下来是

    R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch)

不跟着收的后果正是这条阈值最怕的那件事:**预检按方形判、出帧按非方出**。
2026-08-11 实测,一个刚好过检(w/h=3.0968)的主体在各档画布上的交付占高:

    256×256   0.3086      512×512  0.3105      1024×1024  0.3105
    384×512   0.2324  ← 阈值本意保证的下限是 FILL_H/2 = 0.31,被架空

新增 reject_aspect_for(canvas) 算实际上限,check_master 收可选 canvas,
CharacterGenerator 把**出帧用的同一个 canvas** 传给预检。
canvas=None 或方形画布时与本提交之前完全一致(用例钉死 128/256/512/1024 四档
以及 None 都等于原 REJECT_ASPECT)。

修好之后的不变式实测(源画幅放大到 3000×600 杜绝主体被源边界裁掉;处在各自比例
上限的主体,交付占高应恒等于 FILL_H/2 = 0.31):

    256×256 上限 3.0968 → 0.3086      512×512 上限 3.0968 → 0.3105
    1024×1024 上限 3.0968 → 0.3105    384×512 上限 2.3226 → 0.3105
    512×384 上限 4.1290 → 0.3099      128×192 上限 2.0645 → 0.3125
    640×480 上限 4.1290 → 0.3104      2048×2048 上限 3.0968 → 0.3101
    与 FILL_H/2 的最大偏差 0.0025(取整噪声量级)

即窄高画布收紧、宽扁画布放宽,两侧都回到同一条几何。

变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  M1 非方画布不收紧阈值            → narrow_canvas_tightens 等 3 条红
  M2 宽高比取倒数(方向反了)      → narrow_canvas_tightens 等 3 条红
  M3 方形画布也被改动              → square_canvas_is_unchanged 红
  M4 判定仍用写死的 REJECT_ASPECT  → check_master_uses_the_canvas 红
  M5 预检不吃 canvas               → precheck_and_output_share_geometry 红

M5 一开始杀不掉(没有任何用例覆盖"预检与出帧用了不同 canvas"),补
test_precheck_and_output_share_the_same_canvas_geometry 之后才杀掉 —— 取一个夹在
方形阈值与 384×512 阈值之间的母版,方形放行、窄高必拒。

**依赖上游分支**:同 013520f,需要 feat/ai-engine-frame-toolkit 的 5da358e。
在工作区打上该提交的 pack.py 后跑,CI 全绿:ruff / lint-imports(2 contracts kept)
/ pytest 288 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1024XEngineer#179 合入 main 后重排本分支,解冲突时对 7 个文件取了基座版,把本分支自己的实现覆盖了:
character_generator 丢了母版预检与成色量化(退回 123 行前的旧版)、prompt 三个模板丢了
装备参数化、concrete 丢了 canvas 传递、impl/__init__ 整个丢失。

同时把 framework/pyproject.toml 与 uv.lock 取回 main 版再重锁:本分支的旧 lock 少 254 行、
且 pyproject 删掉了 1024XEngineer#179 已入库的 passlib/redis/resend 三条声明,导致 bcrypt 找不到。

现在 lock 相对 main 是纯新增 43 行(imageio + av)。302 passed。
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-ports-and-strategy branch from 15fc2bc to d4ac9b6 Compare August 12, 2026 02:42
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

#180 合入后重排到最新 main,冲突已解,CI 全绿。

冲突是 #180 的 9 个 ai_engine 文件现在 main 上有了,而本分支还带着自己的副本。这些副本都是 main 版的超集(quality 多了 dead_frame_indices / motion_scale / loop_seam,prompt 的 facing 改用 Facing 枚举),保留的是本分支版本。

@huyanxius
huyanxius requested a review from xiaocheny214 August 12, 2026 02:50

@huyanxius huyanxius left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同一条 ProgressPort 上混了两套 total,进度会往回跳。

impl/character_generator.py 按 4 步报刻度:

progress.step("precheck", 0, 4, facts.note())                       # L61
progress.step("route",    1, 4, f"{action.action.value}{route.value}")   # L68
progress.step("lastmile", 2, 4, "脚线对齐(原地)")                    # L147
progress.step("package",  3, 4, ...)                                 # L101

中间夹着的 strategy/concrete.py 按 3 步报:

progress.step("derive", 0, 3, f"{action.action.value}: i2v 生成视频")        # L70
progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图")         # L84 / L87
progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)")                     # L96 / L106

实跑确认(只顶替 extract_all_frames_bytes,不解码 mp4 / 不联网,其余走分支上的真实路径;ActionSpec(action=WALK, n_frames=8, stylize="none"),canvas 256×256):

  precheck  0/4     0.0%
  route     1/4    25.0%
  derive    0/3     0.0%
  derive    1/3    33.3%
  derive    2/3    66.7%
  lastmile  2/4    50.0%
  package   3/4    75.0%

totals 出现过的取值: [3, 4]
进度回退次数: 2 —— 25.0% → 0.0%,66.7% → 50.0%

ports.ProgressPort 的 docstring 写明这串要经 server 转 SSE 给用户看,#182 也确实是这么接的;消费方按 i/total 画进度条就会看到它倒退两次。这跟本 PR 里 fps / loop / palette 那几处删除是同一条理由——一个量有两个真相源,取哪个看消费方心情。

修法两条选一:把 strategy 的三步并进 generator 的刻度(generator 传一个偏移/总数进去,或由 generator 代报),或者在 ProgressPort 契约上把 (i, total) 明确成"分组内进度"、另给一个组标识,让消费方知道不能跨组比。

其余看下来这个 PR 是扎实的:四类静默失败确实都改成在边界上抛,_subject 把主体判据收成一份、REJECT_ASPECTpack.py 的填充比推出来并用测试锁住推导关系,都是对的。

Comment thread backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py Outdated
评审实跑逮到的:generator 按 i/4 报,中间夹着的 strategy.derive 按 i/3 报到**同一个**
ProgressPort 上。消费方按 i/total 画条会看到倒退两次 —— route 25.0% → derive 0.0%、
derive 66.7% → lastmile 50.0%,totals 同时出现 3 和 4。一个量两个真相源,取哪个看
消费方心情,与本分片删掉 fps / loop / palette 是同一条理由。

改法取评审给的第一条(generator 传偏移):generator 独占全局刻度 total=10,strategy 的
子进度由 _BandProgress 线性映射进 derive 区间 [2,7]。

- strategy 侧零改动。适配器只读它每次调用时自报的 total,不要求它声明自己有几步——
  声明值与实际值又是一对可以对不上的真相源。也不让它知道外层有几步:它是可插拔件,
  各路线步数本就不同。
- 刻度取 10 不取 5,是为了给 derive 段留出中间刻度;否则子进度全落同一格,虽不倒退
  但最慢的那段整段不动。
- 修后序列:0 → 10 → 20 → 30 → 50 → 80 → 90%,单一 total,零倒退。

测试 +3:同一次生成只允许一个 total、进度非递减、子进度必须落在 derive 区间内且
区间内确实动过(只断言"不倒退"的话,把适配器换成"永远报区间起点"也能过)。这三条
打在修复前的代码上全部 FAIL,报的正是评审给的那两处倒退。

一处如实说明:ProgressPort 的 docstring 写的是"server 转 SSE / 轮询状态",但 1024XEngineer#182 目前
唯一的实现是 executor.py:137 的 logger.info,SSE payload 里没有进度字段。所以今天这个
倒退只落在日志里,还没被用户看到 —— 也正因为没有活消费方依赖 total==4,才能直接改刻度
而不必兼容旧值。

Refs 1024XEngineer#171 1024XEngineer#53

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

已修(693d311):generator 独占刻度 total=10,strategy 的子进度由适配器映射进 [2,7],序列成 0/10/20/30/50/80/90%,零倒退。补了 3 条测试(单一 total、非递减、子进度必须落在区间内且真的动过),打在修复前的代码上全部 FAIL。

更正一处:ProgressPort 目前唯一的实现是 executor.py:137 的 logger,SSE payload 里没有进度字段,所以这个倒退还只落在日志里、没到用户眼前。也正因为没有活消费方依赖 total==4,才敢直接改刻度不做兼容。

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

@huyanxius 进度倒退那条已修(693d311,序列 0/10/20/30/50/80/90%,补了 3 条测试打在修复前代码上全 FAIL),CI 全绿,xiaocheny214 已 approve。你这条 CHANGES_REQUESTED 是现在唯一挡着的了,麻烦再看一眼。

@huyanxius huyanxius left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

没问题

@johnnyzhang-eng
johnnyzhang-eng merged commit 40cf126 into 1024XEngineer:main Aug 12, 2026
7 checks passed
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。1024XEngineer#181 合入 main 后
1024XEngineer#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs 1024XEngineer#171 · Refs 1024XEngineer#78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。1024XEngineer#181 合入 main 后
1024XEngineer#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs 1024XEngineer#171 · Refs 1024XEngineer#78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
huyanxius pushed a commit that referenced this pull request Aug 12, 2026
* feat(framework): 补 provider 抽象接口与抠图/视频实现

providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。

* feat(framework): 补 provider 抽象接口与抠图/视频实现

providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。

* feat(ai_engine): 对外契约 ports + 动作分流 strategy + 串联 CharacterGenerator

server 与生成引擎之间的唯一边界,以及"哪个动作走哪条生成路线"这个架构决策。

ports/  server 只 import 这里,由 CI 的 import-linter 分层门禁强制。
  CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction
  边界:ai_engine 只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由
  server 从 Character.reference_image_url 取好以 bytes 传入;产出的帧由 server 上传
  对象存储、写 character_data。依据是"谁掌握租户与配额上下文"——bucket、路径规则、
  归属项目、配额全在 server;ai_engine 自持存储等于把租户概念下沉到一个只做图像计算
  的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受)。

strategy/  ROUTE_MATRIX 是实测挣得的架构契约,改它 = 改产线。
  walk / run / jump / attack / idle -> VIDEO_I2V;hit -> PER_FRAME
  依据:逐帧独立生成锁不住"哪条腿在前"(踢踏舞),视频天生连贯、腿自然交替;
  hit 这类离散姿势单帧可编辑价值高、无连续步态。Refs #35 #53。

impl/CharacterGenerator  选路线 -> strategy.derive 出帧 -> 脚线对齐 -> GeneratedAction。

与 #53 原设计的两处差异:

1) idle 从 PROC_IDLE 改走 VIDEO_I2V,GenRoute.PROC_IDLE 与 ProcIdleStrategy 一并移除。
   #53 原设计 idle 走 ¥0 的程序化局部呼吸(Idle-B),实测做不出可用效果,放弃,认这份
   i2v 的钱。不留没有实现的枚举值。

2) 未实现的路线抛错,不返回空帧。
   旧桩实现 return [b""] * n_frames,调用方拿到的 GeneratedAction 帧数对、时长对、
   无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进
   character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。
   现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出
   已装配了哪些;strategy 吐出空帧时抛 ValueError。四条回归测试拿旧实现对照过,
   确认在修复前全部失败。

另记录 ROUTE_MATRIX 形状的已知边界:它是「动作类型 → 路线」一对一映射,隐含前提是
"路线由动作的物理性质唯一决定"。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立——
同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。
接入第三条路线前须先定「路线选择由谁决定」。

本分支 stack 在 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 之上,那三个合并后 rebase。

* fix(generation): 修机器审报的五处 P1

`_get_generator()` 里还留着 `GenRoute.PROC_IDLE: ProcIdleStrategy(...)`,而这两个都已
随「程序化待机放弃」删除。注入 generator 的测试走不到这条装配路径,所以测试全绿而真实
调用全崩。改为只装当前 GenRoute 真有的路线,并加一条漏装断言——将来新增枚举成员时会
在装配处立刻暴露,而不是等某个动作第一次被请求。

`_download_master` / `_download` 直接 `httpx.get(input.reference_image_urls[0])`,
而那个 URL 来自已认证请求的请求体。等于把服务器当跳板:打 loopback 绕过鉴权中间件、
读云实例元数据服务的临时凭证、探测私网拓扑;重定向还能把合法域名换成上述任意一种。

新增 `_fetch.fetch_own_media`:白名单(必须是 `storage_settings.download_base` 前缀)
+ 禁跟随重定向 + 响应体上限 16 MiB(边读边计数,不信 Content-Length)。

取白名单而非黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、169.254/16、
::1、fc00::/7 以及各种十进制/八进制/IPv6-mapped 写法,漏一条等于没做。而本业务只需拉
自家 bucket 的图(母版与参考图都先经 /media/upload 传上去)。代价是不能再传外部图床
链接——真要支持该走一个显式的「导入外部素材」入口,在那里做完整校验与配额。

`task_repo` 一律发 `task_update`,而 stream 的 `_TERMINAL_EVENTS = {"completed","failed"}`
永不匹配,于是终态 break 走不到。端点带 `retry: 3000`,浏览器原生 EventSource 每 3 秒
重连、每次重收同一条 completed。按状态映射事件名。

`publish` 被后台 daemon thread 调用(executor → task_repo),而队列属于处理 SSE 请求
的那个 loop。`asyncio.Queue` 不是线程安全的。订阅时记下所属 loop,发布时经
`call_soon_threadsafe` 回到那个 loop 再入队;loop 已关闭时静默丢弃(任务状态已落库,
重连后靠 GET /tasks/{id} 取,抛异常会把后台任务整个带崩)。

`num_images` 直通 provider 调用循环,请求模型不设上限——一个已认证请求填个大数就能绕过
按请求计的限流、把成本拉到无上限。加 `ge=1, le=4`;`num_frames` 加 `le=64`;宽高加
`64..2048`。

顺带删掉请求体里的 `user_id: int = Field(gt=0)`:端点已改从 `request.state.current_user`
取归属,这个字段既不被读、又让调用方以为能指定归属者——填别人的 id 不报错也不生效。

20 条回归用例。变异验证中**逮到自己两条摆设测试**并已重写:
- 终态事件名那条原先直接读 `_STATUS_EVENT` 字典,而变异改的是 `_publish_task_update`
  里的用法 → 改为注入假 bus、走真实调用路径断言发出的事件名;
- 跨线程那条证不出 `call_soon_threadsafe` 的必要性(实测裸 `put_nowait` 在单队列场景
  也能被 `get()` 取到,CPython 有元素时走快路径)→ 如实在 docstring 写明本用例强度,
  另补一条「订阅必须记下所属 loop」的结构断言,那条能杀死变异。

漏装断言那条变异存活是**预期**:当前两个路线都装满,`missing` 恒为空集,它是防未来
回归的守卫而非当前行为,测试锁的是「装满」这个事实。

* fix(generation): 请求里的图片尺寸真的生效,并同步上游 provider 两处修复

对抗复查发现:width / height 从请求进到 CharacterImageInput、被 _validate_project_size
校验过,然后被丢掉 —— ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大。调用方
要 512×512、拿到 1024×1024,而请求被接受了。性质与本轮删掉的 ActionSpec.fps / loop 完全
相同,只是这次字段在入口侧。今天还给这两个字段加了 le=2048 上界,等于替它们背书。

模型本身不吃宽高,所以在编排层落实:复用已有的 _fit_to。给它加 smooth 参数——序列帧是
像素画必须 NEAREST(插值会把硬边糊成灰边并引入调色板外的颜色),全彩角色母版反过来,
NEAREST 缩图明显锯齿,用 LANCZOS。

同步上游(改动本体在 feat/provider-interfaces-and-matte):文生图路径改读配置里的
chat_completions_path;400/404 翻译成指向 GET {base}/models 的可操作错误。

测试 +4。变异测试:再把尺寸丢掉 2 条红,smooth 参数不接线 1 条红。其中重采样那条第一版
用纯色图作源是无效仪器(纯色下两种重采样结果完全相同),已换成棋盘格。

* fix(app): 交付尺寸传给引擎出帧,不再拿到帧之后再缩一次

编排层此前对引擎交付的每一帧再做一次 _fit_to(png, sprite_w, sprite_h)。引擎恒出
256,项目要 512 时这就是二次重采样 —— 而实际后果比"糊一次"严重得多:_fit_to 用
Image.thumbnail,**thumbnail 只缩不放**。2026-08-11 复刻这段逻辑实测(喂主体高
157px、脚线 0.92 的 256 交付帧):

    目标 512×512 → 主体仍 157px(根本没放大),脚线 0.92 → 0.709
    目标 384×384 → 主体仍 157px,              脚线 0.92 → 0.779
    目标 128×128 → 主体 78px,                脚线 0.914(缩小方向正常)

即放大方向上主体一点没变大("成品放大看很糊"的直接来源),并且引擎刚用
align_bottom_center 对齐好的脚线被整体挪高,角色不站在地上,ref_height 那套跨动作
本体尺寸一致也一并失效。

改法:把项目 sprite 尺寸作为 canvas 传给 generate,引擎一次出到位,那一步不存在了。

用归档角色「林间斥候」的真实抠图帧(1280×720,主体高中位 619px)端到端实测:

    canvas 不传        交付 256×256   主体高中位 159px
    canvas (256,256)   交付 256×256   主体高中位 159px(与不传**逐字节相同**)
    canvas (512,512)   交付 512×512   主体高中位 318px —— 倍数 2.000
    canvas (384,512)   交付 384×512   主体高与 (512,512) 完全相同(高度几何只看高)
    canvas (1024,1024) 交付 1024×1024 主体高中位 635px

顺带一条选型信息:源帧主体 619px,512 档交付 318px 仍是**下采样**(不引入插值糊),
1024 档 635px 已经越过源分辨率、是上采样,收益递减。

_fit_to 换成 _require_size:只核对、不补救。尺寸对不上说明生成侧没按 canvas 出帧,
该报错让人看见,而不是缩放补边把问题抹平、交付一批脚线错位的帧。_fit_to 本身保留,
角色母版那条路径(smooth=True)仍在用。

**接口影响**:CharacterGeneratorPort.generate 多了 canvas 入参,实现该 Protocol 的
测试替身必须跟着接。修 _SpyGenerator 时顺带发现一个假绿用例:该 spy 一直在传
GeneratedAction(fps=...),而该字段早已删除,构造直接 TypeError、任务其实被判 FAILED;
当时的用例只断言 seen_facing(在构造之前就赋了值)所以一直绿着。已一并修好,
test_project_perspective_constrains_facing 现在跑的是一次真正成功的任务。

变异测试(4 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  E1 不把项目尺寸传给引擎     → project_sprite_size_is_passed 等 2 条红
  E2 canvas 宽高接反          → non_square_sprite_size_passed 红
  E3 只传宽当成方形           → non_square_sprite_size_passed 红
  E4 尺寸不符时静默放行       → wrong_size_fails_instead_of_rescaled 红

**依赖上游分支**:canvas 入参由 feat/ai-engine-ports-and-strategy 的 013520f 提供,
后者又依赖 feat/ai-engine-frame-toolkit 的 5da358e。本分支尚未同步这两个提交,故
单独跑 pytest 会有 1 条 test_action_task_runs_end_to_end 失败(真实
CharacterGenerator 还不认 canvas)。在工作区先打上那两个提交再跑,CI 全绿:
ruff / lint-imports(2 contracts kept) / pytest 309 passed。同步后即恢复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generation): SSE 订阅补归属校验与终态预检,去掉无消费方的 project_id

人工评审报的三条,逐条修:

一、越权订阅。stream 端点只按 task_id 订阅、不看是谁在订阅,任意已认证用户猜到 id
   就能拿到别人任务的实时进度与最终产物 URL(事件体带 result,即帧的对象存储地址)。
   同文件的 GET /tasks/{task_id} 有校验,stream 漏了,而两者从没被放在一起测过。
   校验放在 subscribe **之前**:放之后的话越权请求仍会在 EventBus 上挂一个订阅者,
   照样收事件、只是响应体被丢弃,订阅表还会因为没人 unsubscribe 而增长。

二、终态预检。原先是一行 TODO,而上一行的 docstring 已经承诺了该行为 —— 读文档的人
   不会发现,实际表现是客户端要先挂满 30 秒心跳超时才拿到终态。

三、project_id 声明为必填但从未被使用,归属判定的依据是任务自己的 user_id,不是调用方
   声称的项目。删掉后 fastapi.Query 也变成未使用 import,反过来印证它确实没有消费方。

顺带:事件 payload 从 task_repo 抽成公开的 task_event_payload()。终态预检是第二个发送
点,在 API 层再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的
同名事件。

测试 +9,5 条变异全部杀掉(去掉归属校验 / 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当成 completed)。

其中"payload 同形状"那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源,
删字段一起变、断言永远成立。改成把 SSE 事件体的键集写死成契约清单,并另加一条直接比
两条真实发送路径产出的用例。

过程中三处是测试自身写错、不是代码错,记下避免再犯:按 HTTP 状态码断言越权(本仓
BizException 统一以 200 + 业务码返回,把"校验生效"误判成"越权",且成功码是 200 不是 0);
create_task 签名靠猜;conftest 的建表清单里没有本 PR 新引入的 generation_task 表。

* fix(generation): SSE 归属补齐「任务属于项目」这一道,并落终态预检

**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 #110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 #110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。

* fix(generation): 三个端点接回服务层,并补上能发现"没接上"的测试

评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 #179 移除)。

* fix(deps): framework 依赖声明与 lock 取回 main 版

rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

#181 昨天修过同一处,当时没顺手检查 #182。

* test(orchestrator): 补齐白名单取图的下载段 —— 三条防线此前一行没测

`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。#181 合入 main 后
#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs #171 · Refs #78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants